Skip to content

Commit 81c162c

Browse files
committed
chore: retire v1 account auth endpoints in favor of v2
The v1 login, signup, and password-reset endpoints predate Cloudflare Turnstile and have captcha-less request bodies. Their v2 counterparts (/2/account/login, /signup, /password-reset) require a turnstile token, so the v1 routes are now retired. - v1 POST /1/account/login, /signup, /reset now return 410 Gone with a problem response pointing at the v2 replacement, and are hidden from the OpenAPI document (ApiExplorerSettings.IgnoreApi). - Remove the captcha-less v1 request DTOs (Login, SignUp) and the now-unused CreateAccountWithoutActivationFlowLegacyAsync service method. - Extract the duplicated Turnstile verification block from LoginV2, SignupV2, and PasswordResetInitiateV2 into a shared VerifyTurnstileAsync helper. - Migrate integration tests off the retired routes and add coverage asserting the v1 endpoints respond 410 Gone.
1 parent 4f39ac9 commit 81c162c

16 files changed

Lines changed: 118 additions & 304 deletions

File tree

API.IntegrationTests/Tests/AccountLoginTests.cs

Lines changed: 5 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,60 +10,20 @@ public sealed class AccountLoginTests
1010
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
1111
public required WebApplicationFactory WebApplicationFactory { get; init; }
1212

13-
// --- V1 Login ---
13+
// --- V1 Login (retired) ---
1414

1515
[Test]
16-
public async Task V1Login_Success_ReturnsCookie()
16+
public async Task V1Login_Retired_Returns410Gone()
1717
{
18-
await TestHelper.CreateUserInDb(WebApplicationFactory, "loginv1", "loginv1@test.org", "SecurePassword123#");
19-
20-
using var client = WebApplicationFactory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
21-
{
22-
AllowAutoRedirect = false,
23-
HandleCookies = false
24-
});
25-
26-
var response = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new
27-
{
28-
email = "loginv1@test.org",
29-
password = "SecurePassword123#"
30-
}));
31-
32-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
33-
34-
var setCookie = response.Headers.GetValues("Set-Cookie").ToArray();
35-
var hasSessionCookie = setCookie.Any(c => c.Contains(AuthConstants.UserSessionCookieName));
36-
await Assert.That(hasSessionCookie).IsTrue();
37-
}
38-
39-
[Test]
40-
public async Task V1Login_InvalidPassword_Returns401()
41-
{
42-
await TestHelper.CreateUserInDb(WebApplicationFactory, "loginv1bad", "loginv1bad@test.org", "SecurePassword123#");
43-
4418
using var client = WebApplicationFactory.CreateClient();
4519

4620
var response = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new
4721
{
48-
email = "loginv1bad@test.org",
49-
password = "WrongPassword999!"
50-
}));
51-
52-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
53-
}
54-
55-
[Test]
56-
public async Task V1Login_NonexistentUser_Returns401()
57-
{
58-
using var client = WebApplicationFactory.CreateClient();
59-
60-
var response = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new
61-
{
62-
email = "doesnotexist@test.org",
63-
password = "SomePassword123#"
22+
email = "whatever@test.org",
23+
password = "SecurePassword123#"
6424
}));
6525

66-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
26+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
6727
}
6828

6929
// --- V2 Login ---

API.IntegrationTests/Tests/AccountSignupTests.cs

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,56 +11,21 @@ public sealed class AccountSignupTests
1111
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
1212
public required WebApplicationFactory WebApplicationFactory { get; init; }
1313

14-
// --- V1 Signup ---
14+
// --- V1 Signup (retired) ---
1515

1616
[Test]
17-
public async Task V1Signup_Success_CreatesUser()
17+
public async Task V1Signup_Retired_Returns410Gone()
1818
{
1919
using var client = WebApplicationFactory.CreateClient();
2020

2121
var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new
2222
{
23-
username = "v1user",
23+
username = "v1retired",
2424
password = "SecurePassword123#",
25-
email = "v1user@test.org"
25+
email = "v1retired@test.org"
2626
}));
2727

28-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
29-
30-
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
31-
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
32-
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == "v1user@test.org");
33-
await Assert.That(user).IsNotNull();
34-
}
35-
36-
[Test, DependsOn(nameof(V1Signup_Success_CreatesUser))]
37-
public async Task V1Signup_DuplicateEmail_Returns409()
38-
{
39-
using var client = WebApplicationFactory.CreateClient();
40-
41-
var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new
42-
{
43-
username = "v1userDifferent",
44-
password = "SecurePassword123#",
45-
email = "v1user@test.org" // same email
46-
}));
47-
48-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
49-
}
50-
51-
[Test, DependsOn(nameof(V1Signup_Success_CreatesUser))]
52-
public async Task V1Signup_DuplicateUsername_Returns409()
53-
{
54-
using var client = WebApplicationFactory.CreateClient();
55-
56-
var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new
57-
{
58-
username = "v1user", // same username
59-
password = "SecurePassword123#",
60-
email = "v1different@test.org"
61-
}));
62-
63-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
28+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
6429
}
6530

6631
// --- V2 Signup ---
@@ -118,6 +83,22 @@ public async Task V2Signup_DuplicateEmail_Returns409()
11883
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
11984
}
12085

86+
[Test, DependsOn(nameof(V2Signup_Success_CreatesUser))]
87+
public async Task V2Signup_DuplicateUsername_Returns409()
88+
{
89+
using var client = WebApplicationFactory.CreateClient();
90+
91+
var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new
92+
{
93+
username = "v2user", // same username
94+
password = "SecurePassword123#",
95+
email = "v2different@test.org",
96+
turnstileResponse = "valid-token"
97+
}));
98+
99+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
100+
}
101+
121102
// --- Validation ---
122103

123104
[Test]

API.IntegrationTests/Tests/MailTests.cs

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -86,22 +86,12 @@ public async Task ActivationFlow_ViaEmailLink_ActivatesAccount()
8686
// --- Password Reset ---
8787

8888
[Test]
89-
public async Task V1PasswordReset_SendsPasswordResetEmail()
89+
public async Task V1PasswordReset_Retired_Returns410Gone()
9090
{
91-
var email = TestHelper.UniqueEmail("mail-pwreset");
92-
var username = TestHelper.UniqueUsername("mailpwreset");
93-
using var mailpit = WebApplicationFactory.CreateMailpitHelper();
94-
95-
await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");
96-
9791
using var client = WebApplicationFactory.CreateClient();
98-
var response = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
92+
var response = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email = "whatever@test.org" }));
9993

100-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
101-
102-
var message = await mailpit.WaitForMessageAsync(email);
103-
await Assert.That(message).IsNotNull();
104-
await Assert.That(message!.To?.Select(c => c.Address)).Contains(email);
94+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
10595
}
10696

10797
[Test]
@@ -140,7 +130,7 @@ public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword()
140130
using var client = WebApplicationFactory.CreateClient();
141131

142132
// Initiate password reset
143-
var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
133+
var resetResponse = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
144134
await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
145135

146136
// Wait for reset email and extract the link
@@ -166,10 +156,11 @@ public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword()
166156
await Assert.That(completeResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
167157

168158
// Confirm we can log in with the new password
169-
var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new
159+
var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new
170160
{
171-
email,
172-
password = newPassword
161+
usernameOrEmail = email,
162+
password = newPassword,
163+
turnstileResponse = "valid-token"
173164
}));
174165
await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
175166
}
@@ -331,7 +322,7 @@ public async Task PasswordResetComplete_LegacyRecoverRoute_StillWorks()
331322

332323
using var client = WebApplicationFactory.CreateClient();
333324

334-
var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
325+
var resetResponse = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
335326
await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
336327

337328
var message = await mailpit.WaitForMessageAsync(email);
@@ -346,10 +337,11 @@ public async Task PasswordResetComplete_LegacyRecoverRoute_StillWorks()
346337
TestHelper.JsonContent(new { password = newPassword }));
347338
await Assert.That(completeResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
348339

349-
var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new
340+
var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new
350341
{
351-
email,
352-
password = newPassword
342+
usernameOrEmail = email,
343+
password = newPassword,
344+
turnstileResponse = "valid-token"
353345
}));
354346
await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
355347
}
@@ -364,7 +356,7 @@ public async Task PasswordResetCheck_LegacyHeadRecoverRoute_StillWorks()
364356
await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");
365357

366358
using var client = WebApplicationFactory.CreateClient();
367-
var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
359+
var resetResponse = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
368360
await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
369361

370362
var message = await mailpit.WaitForMessageAsync(email);
@@ -463,10 +455,10 @@ public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompl
463455
using var client = WebApplicationFactory.CreateClient();
464456

465457
// Fire two reset requests back-to-back, then wait for both emails to land.
466-
var firstInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
458+
var firstInit = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
467459
await Assert.That(firstInit.StatusCode).IsEqualTo(HttpStatusCode.OK);
468460

469-
var secondInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
461+
var secondInit = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
470462
await Assert.That(secondInit.StatusCode).IsEqualTo(HttpStatusCode.OK);
471463

472464
var messages = await mailpit.WaitForMessagesAsync(email, minCount: 2);
@@ -500,10 +492,11 @@ public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompl
500492
await Assert.That(completeB.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
501493

502494
// Password from the winning reset works
503-
var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new
495+
var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new
504496
{
505-
email,
506-
password = firstNewPassword
497+
usernameOrEmail = email,
498+
password = firstNewPassword,
499+
turnstileResponse = "valid-token"
507500
}));
508501
await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
509502
}

API.IntegrationTests/Tests/RegistrationDisabledTests.cs

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,6 @@ public sealed class RegistrationDisabledTests
1414
[ClassDataSource<RegistrationDisabledWebApplicationFactory>(Shared = SharedType.PerTestSession)]
1515
public required RegistrationDisabledWebApplicationFactory WebApplicationFactory { get; init; }
1616

17-
[Test]
18-
public async Task V1Signup_RegistrationDisabled_Returns403WithProblemType()
19-
{
20-
using var client = WebApplicationFactory.CreateClient();
21-
22-
var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new
23-
{
24-
username = "disabledv1",
25-
password = "SecurePassword123#",
26-
email = "disabledv1@test.org"
27-
}));
28-
29-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
30-
31-
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
32-
await Assert.That(problem).IsNotNull();
33-
await Assert.That(problem!.Type).IsEqualTo("Signup.RegistrationDisabled");
34-
}
35-
3617
[Test]
3718
public async Task V2Signup_RegistrationDisabled_Returns403WithProblemType()
3819
{

API/Controller/Account/Login.cs

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,17 @@
1-
using Asp.Versioning;
1+
using Asp.Versioning;
22
using Microsoft.AspNetCore.Mvc;
3-
using OpenShock.API.Models.Requests;
43
using OpenShock.Common.Errors;
5-
using OpenShock.Common.Models;
6-
using OpenShock.Common.Problems;
7-
using System.Net.Mime;
8-
using Microsoft.AspNetCore.RateLimiting;
94

105
namespace OpenShock.API.Controller.Account;
116

127
public sealed partial class AccountController
138
{
149
/// <summary>
15-
/// Authenticate a user
10+
/// Authenticate a user. Retired: use POST /2/account/login instead.
1611
/// </summary>
17-
/// <response code="200">User successfully logged in</response>
18-
/// <response code="401">Invalid username or password</response>
1912
[HttpPost("login")]
20-
[EnableRateLimiting("auth")]
21-
[Consumes(MediaTypeNames.Application.Json)]
22-
[ProducesResponseType<LegacyEmptyResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
23-
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // InvalidCredentials
24-
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain
13+
[Obsolete("Retired. Use POST /2/account/login instead.")]
14+
[ApiExplorerSettings(IgnoreApi = true)]
2515
[MapToApiVersion("1")]
26-
public async Task<IActionResult> Login(
27-
[FromBody] Login body,
28-
CancellationToken cancellationToken)
29-
{
30-
var cookieDomain = GetCurrentCookieDomain();
31-
if (cookieDomain is null) return Problem(LoginError.InvalidDomain);
32-
33-
var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.Email, body.Password, cancellationToken);
34-
if (!getAccountResult.TryPickT0(out var account, out var errors))
35-
{
36-
return errors.Match(
37-
notFound => Problem(LoginError.InvalidCredentials),
38-
deactivated => Problem(AccountError.AccountDeactivated),
39-
notActivated => Problem(AccountError.AccountNotActivated),
40-
oauthOnly => Problem(AccountError.AccountOAuthOnly)
41-
);
42-
}
43-
44-
await CreateSession(account.Id, cookieDomain);
45-
return LegacyEmptyOk("Successfully logged in");
46-
}
47-
}
16+
public IActionResult Login() => Problem(GoneError.EndpointRetired("POST /2/account/login"));
17+
}

API/Controller/Account/LoginV2.cs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
using Microsoft.AspNetCore.Mvc;
22
using OpenShock.API.Models.Requests;
3-
using System.Net;
43
using System.Net.Mime;
54
using Asp.Versioning;
65
using Microsoft.AspNetCore.RateLimiting;
76
using OpenShock.Common.Errors;
87
using OpenShock.Common.Problems;
9-
using OpenShock.Common.Utils;
10-
using OpenShock.API.Errors;
118
using OpenShock.API.Models.Response;
129
using OpenShock.API.Services.Turnstile;
1310

@@ -35,17 +32,9 @@ public async Task<IActionResult> LoginV2(
3532
var cookieDomain = GetCurrentCookieDomain();
3633
if (cookieDomain is null) return Problem(LoginError.InvalidDomain);
3734

38-
var remoteIp = HttpContext.GetRemoteIP();
35+
var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken);
36+
if (turnstileError is not null) return turnstileError;
3937

40-
var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIp, cancellationToken);
41-
if (!turnStile.TryPickT0(out _, out var cfErrors))
42-
{
43-
if (cfErrors.Value.All(err => err == CloudflareTurnstileError.InvalidResponse))
44-
return Problem(TurnstileError.InvalidTurnstile);
45-
46-
return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError));
47-
}
48-
4938
var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken);
5039
if (!getAccountResult.TryPickT0(out var account, out var errors))
5140
{

0 commit comments

Comments
 (0)