From 81c162c9ef185335eaac8bf64d64f4dc823e4092 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 24 Jun 2026 11:59:31 +0200 Subject: [PATCH 1/3] 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. --- .../Tests/AccountLoginTests.cs | 50 ++------------- .../Tests/AccountSignupTests.cs | 61 +++++++------------ API.IntegrationTests/Tests/MailTests.cs | 47 ++++++-------- .../Tests/RegistrationDisabledTests.cs | 19 ------ API/Controller/Account/Login.cs | 42 ++----------- API/Controller/Account/LoginV2.cs | 15 +---- .../Account/PasswordResetInitiate.cs | 29 +++------ .../Account/PasswordResetInitiateV2.cs | 17 +----- API/Controller/Account/Signup.cs | 38 ++---------- API/Controller/Account/SignupV2.cs | 14 +---- API/Controller/Account/_Turnstile.cs | 27 ++++++++ API/Models/Requests/Login.cs | 12 ---- API/Models/Requests/Signup.cs | 15 ----- API/Services/Account/AccountService.cs | 10 +-- API/Services/Account/IAccountService.cs | 9 --- Common/Errors/GoneError.cs | 17 ++++++ 16 files changed, 118 insertions(+), 304 deletions(-) create mode 100644 API/Controller/Account/_Turnstile.cs delete mode 100644 API/Models/Requests/Login.cs delete mode 100644 API/Models/Requests/Signup.cs create mode 100644 Common/Errors/GoneError.cs diff --git a/API.IntegrationTests/Tests/AccountLoginTests.cs b/API.IntegrationTests/Tests/AccountLoginTests.cs index d8462821..e6b293eb 100644 --- a/API.IntegrationTests/Tests/AccountLoginTests.cs +++ b/API.IntegrationTests/Tests/AccountLoginTests.cs @@ -10,60 +10,20 @@ public sealed class AccountLoginTests [ClassDataSource(Shared = SharedType.PerTestSession)] public required WebApplicationFactory WebApplicationFactory { get; init; } - // --- V1 Login --- + // --- V1 Login (retired) --- [Test] - public async Task V1Login_Success_ReturnsCookie() + public async Task V1Login_Retired_Returns410Gone() { - await TestHelper.CreateUserInDb(WebApplicationFactory, "loginv1", "loginv1@test.org", "SecurePassword123#"); - - using var client = WebApplicationFactory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false, - HandleCookies = false - }); - - var response = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new - { - email = "loginv1@test.org", - password = "SecurePassword123#" - })); - - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - - var setCookie = response.Headers.GetValues("Set-Cookie").ToArray(); - var hasSessionCookie = setCookie.Any(c => c.Contains(AuthConstants.UserSessionCookieName)); - await Assert.That(hasSessionCookie).IsTrue(); - } - - [Test] - public async Task V1Login_InvalidPassword_Returns401() - { - await TestHelper.CreateUserInDb(WebApplicationFactory, "loginv1bad", "loginv1bad@test.org", "SecurePassword123#"); - using var client = WebApplicationFactory.CreateClient(); var response = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new { - email = "loginv1bad@test.org", - password = "WrongPassword999!" - })); - - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); - } - - [Test] - public async Task V1Login_NonexistentUser_Returns401() - { - using var client = WebApplicationFactory.CreateClient(); - - var response = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new - { - email = "doesnotexist@test.org", - password = "SomePassword123#" + email = "whatever@test.org", + password = "SecurePassword123#" })); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone); } // --- V2 Login --- diff --git a/API.IntegrationTests/Tests/AccountSignupTests.cs b/API.IntegrationTests/Tests/AccountSignupTests.cs index d8f1cb38..3ae07716 100644 --- a/API.IntegrationTests/Tests/AccountSignupTests.cs +++ b/API.IntegrationTests/Tests/AccountSignupTests.cs @@ -11,56 +11,21 @@ public sealed class AccountSignupTests [ClassDataSource(Shared = SharedType.PerTestSession)] public required WebApplicationFactory WebApplicationFactory { get; init; } - // --- V1 Signup --- + // --- V1 Signup (retired) --- [Test] - public async Task V1Signup_Success_CreatesUser() + public async Task V1Signup_Retired_Returns410Gone() { using var client = WebApplicationFactory.CreateClient(); var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new { - username = "v1user", + username = "v1retired", password = "SecurePassword123#", - email = "v1user@test.org" + email = "v1retired@test.org" })); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - - await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var user = await db.Users.FirstOrDefaultAsync(u => u.Email == "v1user@test.org"); - await Assert.That(user).IsNotNull(); - } - - [Test, DependsOn(nameof(V1Signup_Success_CreatesUser))] - public async Task V1Signup_DuplicateEmail_Returns409() - { - using var client = WebApplicationFactory.CreateClient(); - - var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new - { - username = "v1userDifferent", - password = "SecurePassword123#", - email = "v1user@test.org" // same email - })); - - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); - } - - [Test, DependsOn(nameof(V1Signup_Success_CreatesUser))] - public async Task V1Signup_DuplicateUsername_Returns409() - { - using var client = WebApplicationFactory.CreateClient(); - - var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new - { - username = "v1user", // same username - password = "SecurePassword123#", - email = "v1different@test.org" - })); - - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone); } // --- V2 Signup --- @@ -118,6 +83,22 @@ public async Task V2Signup_DuplicateEmail_Returns409() await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); } + [Test, DependsOn(nameof(V2Signup_Success_CreatesUser))] + public async Task V2Signup_DuplicateUsername_Returns409() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = "v2user", // same username + password = "SecurePassword123#", + email = "v2different@test.org", + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + // --- Validation --- [Test] diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 00fbb1d4..87d4c8bb 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -86,22 +86,12 @@ public async Task ActivationFlow_ViaEmailLink_ActivatesAccount() // --- Password Reset --- [Test] - public async Task V1PasswordReset_SendsPasswordResetEmail() + public async Task V1PasswordReset_Retired_Returns410Gone() { - var email = TestHelper.UniqueEmail("mail-pwreset"); - var username = TestHelper.UniqueUsername("mailpwreset"); - using var mailpit = WebApplicationFactory.CreateMailpitHelper(); - - await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); - using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + var response = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email = "whatever@test.org" })); - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); - - var message = await mailpit.WaitForMessageAsync(email); - await Assert.That(message).IsNotNull(); - await Assert.That(message!.To?.Select(c => c.Address)).Contains(email); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone); } [Test] @@ -140,7 +130,7 @@ public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword() using var client = WebApplicationFactory.CreateClient(); // Initiate password reset - var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + var resetResponse = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); // Wait for reset email and extract the link @@ -166,10 +156,11 @@ public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword() await Assert.That(completeResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); // Confirm we can log in with the new password - var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new + var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new { - email, - password = newPassword + usernameOrEmail = email, + password = newPassword, + turnstileResponse = "valid-token" })); await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); } @@ -331,7 +322,7 @@ public async Task PasswordResetComplete_LegacyRecoverRoute_StillWorks() using var client = WebApplicationFactory.CreateClient(); - var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + var resetResponse = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); var message = await mailpit.WaitForMessageAsync(email); @@ -346,10 +337,11 @@ public async Task PasswordResetComplete_LegacyRecoverRoute_StillWorks() TestHelper.JsonContent(new { password = newPassword })); await Assert.That(completeResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); - var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new + var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new { - email, - password = newPassword + usernameOrEmail = email, + password = newPassword, + turnstileResponse = "valid-token" })); await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); } @@ -364,7 +356,7 @@ public async Task PasswordResetCheck_LegacyHeadRecoverRoute_StillWorks() await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); using var client = WebApplicationFactory.CreateClient(); - var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + var resetResponse = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); var message = await mailpit.WaitForMessageAsync(email); @@ -463,10 +455,10 @@ public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompl using var client = WebApplicationFactory.CreateClient(); // Fire two reset requests back-to-back, then wait for both emails to land. - var firstInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + var firstInit = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); await Assert.That(firstInit.StatusCode).IsEqualTo(HttpStatusCode.OK); - var secondInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + var secondInit = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); await Assert.That(secondInit.StatusCode).IsEqualTo(HttpStatusCode.OK); var messages = await mailpit.WaitForMessagesAsync(email, minCount: 2); @@ -500,10 +492,11 @@ public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompl await Assert.That(completeB.StatusCode).IsEqualTo(HttpStatusCode.NotFound); // Password from the winning reset works - var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new + var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new { - email, - password = firstNewPassword + usernameOrEmail = email, + password = firstNewPassword, + turnstileResponse = "valid-token" })); await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); } diff --git a/API.IntegrationTests/Tests/RegistrationDisabledTests.cs b/API.IntegrationTests/Tests/RegistrationDisabledTests.cs index 70cb777f..e5cea246 100644 --- a/API.IntegrationTests/Tests/RegistrationDisabledTests.cs +++ b/API.IntegrationTests/Tests/RegistrationDisabledTests.cs @@ -14,25 +14,6 @@ public sealed class RegistrationDisabledTests [ClassDataSource(Shared = SharedType.PerTestSession)] public required RegistrationDisabledWebApplicationFactory WebApplicationFactory { get; init; } - [Test] - public async Task V1Signup_RegistrationDisabled_Returns403WithProblemType() - { - using var client = WebApplicationFactory.CreateClient(); - - var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new - { - username = "disabledv1", - password = "SecurePassword123#", - email = "disabledv1@test.org" - })); - - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); - - var problem = await response.Content.ReadFromJsonAsync(); - await Assert.That(problem).IsNotNull(); - await Assert.That(problem!.Type).IsEqualTo("Signup.RegistrationDisabled"); - } - [Test] public async Task V2Signup_RegistrationDisabled_Returns403WithProblemType() { diff --git a/API/Controller/Account/Login.cs b/API/Controller/Account/Login.cs index 6ec536da..7503c334 100644 --- a/API/Controller/Account/Login.cs +++ b/API/Controller/Account/Login.cs @@ -1,47 +1,17 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Mvc; -using OpenShock.API.Models.Requests; using OpenShock.Common.Errors; -using OpenShock.Common.Models; -using OpenShock.Common.Problems; -using System.Net.Mime; -using Microsoft.AspNetCore.RateLimiting; namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { /// - /// Authenticate a user + /// Authenticate a user. Retired: use POST /2/account/login instead. /// - /// User successfully logged in - /// Invalid username or password [HttpPost("login")] - [EnableRateLimiting("auth")] - [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // InvalidCredentials - [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain + [Obsolete("Retired. Use POST /2/account/login instead.")] + [ApiExplorerSettings(IgnoreApi = true)] [MapToApiVersion("1")] - public async Task Login( - [FromBody] Login body, - CancellationToken cancellationToken) - { - var cookieDomain = GetCurrentCookieDomain(); - if (cookieDomain is null) return Problem(LoginError.InvalidDomain); - - var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.Email, body.Password, cancellationToken); - if (!getAccountResult.TryPickT0(out var account, out var errors)) - { - return errors.Match( - notFound => Problem(LoginError.InvalidCredentials), - deactivated => Problem(AccountError.AccountDeactivated), - notActivated => Problem(AccountError.AccountNotActivated), - oauthOnly => Problem(AccountError.AccountOAuthOnly) - ); - } - - await CreateSession(account.Id, cookieDomain); - return LegacyEmptyOk("Successfully logged in"); - } -} \ No newline at end of file + public IActionResult Login() => Problem(GoneError.EndpointRetired("POST /2/account/login")); +} diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 0fa3e807..43ae9c7c 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -1,13 +1,10 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; -using System.Net; using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; using OpenShock.Common.Errors; using OpenShock.Common.Problems; -using OpenShock.Common.Utils; -using OpenShock.API.Errors; using OpenShock.API.Models.Response; using OpenShock.API.Services.Turnstile; @@ -35,17 +32,9 @@ public async Task LoginV2( var cookieDomain = GetCurrentCookieDomain(); if (cookieDomain is null) return Problem(LoginError.InvalidDomain); - var remoteIp = HttpContext.GetRemoteIP(); + var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); + if (turnstileError is not null) return turnstileError; - var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, remoteIp, cancellationToken); - if (!turnStile.TryPickT0(out _, out var cfErrors)) - { - if (cfErrors.Value.All(err => err == CloudflareTurnstileError.InvalidResponse)) - return Problem(TurnstileError.InvalidTurnstile); - - return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); - } - var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.UsernameOrEmail, body.Password, cancellationToken); if (!getAccountResult.TryPickT0(out var account, out var errors)) { diff --git a/API/Controller/Account/PasswordResetInitiate.cs b/API/Controller/Account/PasswordResetInitiate.cs index 945be724..53a1abe1 100644 --- a/API/Controller/Account/PasswordResetInitiate.cs +++ b/API/Controller/Account/PasswordResetInitiate.cs @@ -1,32 +1,17 @@ -using System.Net.Mime; -using Microsoft.AspNetCore.Mvc; -using OpenShock.Common.Models; using Asp.Versioning; -using Microsoft.AspNetCore.RateLimiting; -using OpenShock.Common.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using OpenShock.Common.Errors; namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { /// - /// Initiate a password reset + /// Initiate a password reset. Retired: use POST /2/account/password-reset instead. /// - /// Password reset email sent if the email is associated to an registered account [HttpPost("reset")] - [EnableRateLimiting("auth")] - [Consumes(MediaTypeNames.Application.Json)] + [Obsolete("Retired. Use POST /2/account/password-reset instead.")] + [ApiExplorerSettings(IgnoreApi = true)] [MapToApiVersion("1")] - [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - public async Task PasswordResetInitiate([FromBody] ResetRequest body) - { - await _accountService.CreatePasswordResetFlowAsync(body.Email); - return LegacyEmptyOk("Password reset has been sent via email if the email is associated to an registered account"); - } - - public sealed class ResetRequest - { - [EmailAddress(true)] - public required string Email { get; init; } - } -} \ No newline at end of file + public IActionResult PasswordResetInitiate() => Problem(GoneError.EndpointRetired("POST /2/account/password-reset")); +} diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 136e0635..55874247 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -1,14 +1,10 @@ -using System; -using System.Net; -using System.Net.Mime; +using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Errors; using OpenShock.API.Models.Requests; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Problems; -using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account; @@ -43,15 +39,8 @@ public Task PasswordResetInitiateV2Legacy([FromBody] PasswordRese private async Task PasswordResetInitiate(PasswordResetRequestV2 body, ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) { - var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken); - if (!turnStile.IsT0) - { - var cfErrors = turnStile.AsT1.Value; - if (cfErrors.All(err => err == CloudflareTurnstileError.InvalidResponse)) - return Problem(TurnstileError.InvalidTurnstile); - - return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); - } + var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); + if (turnstileError is not null) return turnstileError; await _accountService.CreatePasswordResetFlowAsync(body.Email); diff --git a/API/Controller/Account/Signup.cs b/API/Controller/Account/Signup.cs index 26be5318..109f3142 100644 --- a/API/Controller/Account/Signup.cs +++ b/API/Controller/Account/Signup.cs @@ -1,43 +1,17 @@ -using Microsoft.AspNetCore.Mvc; -using OpenShock.API.Models.Requests; -using System.Net.Mime; using Asp.Versioning; -using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.Mvc; using OpenShock.Common.Errors; -using OpenShock.Common.Options; -using OpenShock.Common.Problems; -using OpenShock.Common.Models; namespace OpenShock.API.Controller.Account; public sealed partial class AccountController { /// - /// Signs up a new user + /// Signs up a new user. Retired: use POST /2/account/signup instead. /// - /// - /// - /// User successfully signed up - /// Username or email already exists - /// Account registration is disabled on this instance [HttpPost("signup")] - [EnableRateLimiting("auth")] - [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailOrUsernameAlreadyExists - [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // RegistrationDisabled + [Obsolete("Retired. Use POST /2/account/signup instead.")] + [ApiExplorerSettings(IgnoreApi = true)] [MapToApiVersion("1")] - public async Task SignUp( - [FromBody] SignUp body, - [FromServices] AccountOptions accountOptions) - { - if (!accountOptions.RegistrationEnabled) - return Problem(SignupError.RegistrationDisabled); - - var creationAction = await _accountService.CreateAccountWithoutActivationFlowLegacyAsync(body.Email, body.Username, body.Password); - return creationAction.Match( - ok => LegacyEmptyOk("Successfully signed up"), - alreadyExists => Problem(SignupError.UsernameOrEmailExists) - ); - } -} \ No newline at end of file + public IActionResult SignUp() => Problem(GoneError.EndpointRetired("POST /2/account/signup")); +} diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 5bf1a832..cf3c2f72 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -1,15 +1,12 @@ using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; -using System.Net; using System.Net.Mime; using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; -using OpenShock.API.Errors; using OpenShock.API.Services.Turnstile; using OpenShock.Common.Errors; using OpenShock.Common.Options; using OpenShock.Common.Problems; -using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account; @@ -40,15 +37,8 @@ public async Task SignUpV2( if (!accountOptions.RegistrationEnabled) return Problem(SignupError.RegistrationDisabled); - var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken); - if (!turnStile.IsT0) - { - var cfErrors = turnStile.AsT1.Value; - if (cfErrors.All(err => err == CloudflareTurnstileError.InvalidResponse)) - return Problem(TurnstileError.InvalidTurnstile); - - return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); - } + var turnstileError = await VerifyTurnstileAsync(turnstileService, body.TurnstileResponse, cancellationToken); + if (turnstileError is not null) return turnstileError; var creationAction = await _accountService.CreateAccountWithActivationFlowAsync(body.Email, body.Username, body.Password); return creationAction.Match( diff --git a/API/Controller/Account/_Turnstile.cs b/API/Controller/Account/_Turnstile.cs new file mode 100644 index 00000000..cdbbfcd7 --- /dev/null +++ b/API/Controller/Account/_Turnstile.cs @@ -0,0 +1,27 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.Errors; +using OpenShock.API.Services.Turnstile; +using OpenShock.Common.Problems; +using OpenShock.Common.Utils; + +namespace OpenShock.API.Controller.Account; + +public sealed partial class AccountController +{ + /// + /// Verifies a Cloudflare Turnstile response token for the current request. + /// Returns null when verification succeeds, otherwise the to return to the caller. + /// + private async Task VerifyTurnstileAsync(ICloudflareTurnstileService turnstileService, string turnstileResponse, CancellationToken cancellationToken) + { + var turnStile = await turnstileService.VerifyUserResponseTokenAsync(turnstileResponse, HttpContext.GetRemoteIP(), cancellationToken); + if (turnStile.IsT0) return null; + + var cfErrors = turnStile.AsT1.Value; + if (cfErrors.All(err => err == CloudflareTurnstileError.InvalidResponse)) + return Problem(TurnstileError.InvalidTurnstile); + + return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); + } +} diff --git a/API/Models/Requests/Login.cs b/API/Models/Requests/Login.cs deleted file mode 100644 index 8950eb4a..00000000 --- a/API/Models/Requests/Login.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace OpenShock.API.Models.Requests; - -public sealed class Login -{ - [Required(AllowEmptyStrings = false)] - public required string Password { get; set; } - - [Required(AllowEmptyStrings = false)] - public required string Email { get; set; } // This says email, but it's actually Username or Email. -} \ No newline at end of file diff --git a/API/Models/Requests/Signup.cs b/API/Models/Requests/Signup.cs deleted file mode 100644 index d7134a55..00000000 --- a/API/Models/Requests/Signup.cs +++ /dev/null @@ -1,15 +0,0 @@ -using OpenShock.Common.DataAnnotations; - -namespace OpenShock.API.Models.Requests; - -public sealed class SignUp -{ - [Username(true)] - public required string Username { get; set; } - - [Password(true)] - public required string Password { get; set; } - - [EmailAddress(true)] - public required string Email { get; set; } -} \ No newline at end of file diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 398df273..430e14a0 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -61,7 +61,7 @@ private async Task IsEmailProviderBlacklisted(string email) return await _db.EmailProviderBlacklists.AnyAsync(e => e.Domain == domain); } - private async Task, AccountWithEmailOrUsernameExists>> CreateAccount(string email, string username, string password, bool verifyOnCreation) + private async Task, AccountWithEmailOrUsernameExists>> CreateAccount(string email, string username, string password, bool verifyOnCreation = false) { email = email.ToLowerInvariant(); @@ -92,17 +92,11 @@ await _db.Users return new Success(user); } - - /// - public async Task, AccountWithEmailOrUsernameExists>> CreateAccountWithoutActivationFlowLegacyAsync(string email, string username, string password) - { - return await CreateAccount(email, username, password, true); - } /// public async Task, AccountWithEmailOrUsernameExists>> CreateAccountWithActivationFlowAsync(string email, string username, string password) { - var accountCreate = await CreateAccount(email, username, password, false); + var accountCreate = await CreateAccount(email, username, password); if (accountCreate.IsT1) return accountCreate; var user = accountCreate.AsT0.Value; diff --git a/API/Services/Account/IAccountService.cs b/API/Services/Account/IAccountService.cs index 64c4a1ae..feb389cf 100644 --- a/API/Services/Account/IAccountService.cs +++ b/API/Services/Account/IAccountService.cs @@ -10,15 +10,6 @@ namespace OpenShock.API.Services.Account; /// public interface IAccountService { - /// - /// Creates an account - /// - /// - /// - /// - /// - public Task, AccountWithEmailOrUsernameExists>> CreateAccountWithoutActivationFlowLegacyAsync(string email, string username, string password); - /// /// When a user uses the signup form, this also initiates an account activation flow /// diff --git a/Common/Errors/GoneError.cs b/Common/Errors/GoneError.cs new file mode 100644 index 00000000..027660fe --- /dev/null +++ b/Common/Errors/GoneError.cs @@ -0,0 +1,17 @@ +using System.Net; +using OpenShock.Common.Problems; + +namespace OpenShock.Common.Errors; + +public static class GoneError +{ + /// + /// Returned by retired endpoints. Points the caller at the replacement endpoint. + /// + /// The endpoint to use instead, e.g. POST /2/account/login. + public static OpenShockProblem EndpointRetired(string replacement) => new( + "Endpoint.Retired", + "This endpoint has been retired", + HttpStatusCode.Gone, + $"This endpoint is no longer available. Please use {replacement} instead."); +} From 3aea4bdc87f67d1e4df6ac692b07bc45d3073ae4 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 24 Jun 2026 12:54:10 +0200 Subject: [PATCH 2/3] chore: retire deprecated password-reset aliases with 410 Gone The deprecated /reset-password and /recover password-reset aliases are unused by the new frontend (verified: no call sites outside the generated SDK), so retire them alongside the v1 auth endpoints. - POST /2/account/reset-password, POST /1/account/recover/{id}/{secret}, and HEAD /1/account/recover/{id}/{secret} now return 410 Gone pointing at their canonical replacements, and are hidden from the OpenAPI doc. - Replace the "legacy route still works" tests with 410 Gone assertions and add coverage for the retired /reset-password alias. --- API.IntegrationTests/Tests/MailTests.cs | 67 ++++++------------- .../Account/PasswordResetCheckValid.cs | 15 ++--- .../Account/PasswordResetComplete.cs | 17 ++--- .../Account/PasswordResetInitiateV2.cs | 14 ++-- 4 files changed, 37 insertions(+), 76 deletions(-) diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs index 87d4c8bb..8f8cd793 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -94,6 +94,19 @@ public async Task V1PasswordReset_Retired_Returns410Gone() await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone); } + [Test] + public async Task ResetPasswordAlias_Retired_Returns410Gone() + { + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/2/account/reset-password", TestHelper.JsonContent(new + { + email = "whatever@test.org", + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone); + } + [Test] public async Task V2PasswordReset_SendsPasswordResetEmail() { @@ -311,62 +324,26 @@ public async Task ChangeEmail_Unchanged_Returns400_AndSendsNoEmail() } [Test] - public async Task PasswordResetComplete_LegacyRecoverRoute_StillWorks() + public async Task PasswordResetComplete_LegacyRecoverRoute_Returns410Gone() { - var email = TestHelper.UniqueEmail("mail-pwreset-legacy"); - var username = TestHelper.UniqueUsername("mailpwresetlegacy"); - const string newPassword = "LegacyNewPassword456#"; - using var mailpit = WebApplicationFactory.CreateMailpitHelper(); - - await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); - using var client = WebApplicationFactory.CreateClient(); - var resetResponse = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); - await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + var response = await client.PostAsync( + $"/1/account/recover/{Guid.CreateVersion7()}/somesecret", + TestHelper.JsonContent(new { password = "LegacyNewPassword456#" })); - var message = await mailpit.WaitForMessageAsync(email); - await Assert.That(message).IsNotNull(); - var fullMessage = await mailpit.GetMessageAsync(message!.Id); - var (resetId, secret) = ExtractPasswordResetParams(fullMessage!.Html); - await Assert.That(resetId).IsNotNull().And.IsNotEmpty(); - - // Hit the deprecated route directly — must still complete the reset. - var completeResponse = await client.PostAsync( - $"/1/account/recover/{resetId}/{secret}", - TestHelper.JsonContent(new { password = newPassword })); - await Assert.That(completeResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); - - var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new - { - usernameOrEmail = email, - password = newPassword, - turnstileResponse = "valid-token" - })); - await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone); } [Test] - public async Task PasswordResetCheck_LegacyHeadRecoverRoute_StillWorks() + public async Task PasswordResetCheck_LegacyHeadRecoverRoute_Returns410Gone() { - var email = TestHelper.UniqueEmail("mail-pwreset-check-legacy"); - var username = TestHelper.UniqueUsername("mailpwresetchecklegacy"); - using var mailpit = WebApplicationFactory.CreateMailpitHelper(); - - await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); - using var client = WebApplicationFactory.CreateClient(); - var resetResponse = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" })); - await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); - var message = await mailpit.WaitForMessageAsync(email); - await Assert.That(message).IsNotNull(); - var fullMessage = await mailpit.GetMessageAsync(message!.Id); - var (resetId, secret) = ExtractPasswordResetParams(fullMessage!.Html); + var response = await client.SendAsync(new HttpRequestMessage( + HttpMethod.Head, $"/1/account/recover/{Guid.CreateVersion7()}/somesecret")); - var legacyCheck = await client.SendAsync(new HttpRequestMessage( - HttpMethod.Head, $"/1/account/recover/{resetId}/{secret}")); - await Assert.That(legacyCheck.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone); } [Test] diff --git a/API/Controller/Account/PasswordResetCheckValid.cs b/API/Controller/Account/PasswordResetCheckValid.cs index a509f94c..43430fa3 100644 --- a/API/Controller/Account/PasswordResetCheckValid.cs +++ b/API/Controller/Account/PasswordResetCheckValid.cs @@ -28,21 +28,16 @@ public Task PasswordResetCheckValid([FromRoute] Guid passwordRese => CheckPasswordReset(passwordResetId, secret, cancellationToken); /// - /// Check if a password reset is in progress. Deprecated: use GET /password-reset/{id}/{secret} instead. + /// Check if a password reset is in progress. Retired: use GET /1/account/password-reset/{passwordResetId}/{secret} instead. /// /// The id of the password reset /// The secret of the password reset - /// - /// Valid password reset process - /// Password reset process not found - [Obsolete("Use GET /password-reset/{passwordResetId}/{secret} instead.")] + [Obsolete("Retired. Use GET /1/account/password-reset/{passwordResetId}/{secret} instead.")] [HttpHead("recover/{passwordResetId}/{secret}")] - [EnableRateLimiting("auth")] - [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound + [ApiExplorerSettings(IgnoreApi = true)] [MapToApiVersion("1")] - public Task PasswordResetCheckValidLegacy([FromRoute] Guid passwordResetId, [FromRoute] string secret, CancellationToken cancellationToken) - => CheckPasswordReset(passwordResetId, secret, cancellationToken); + public IActionResult PasswordResetCheckValidLegacy([FromRoute] Guid passwordResetId, [FromRoute] string secret) + => Problem(GoneError.EndpointRetired("GET /1/account/password-reset/{passwordResetId}/{secret}")); private async Task CheckPasswordReset(Guid passwordResetId, string secret, CancellationToken cancellationToken) { diff --git a/API/Controller/Account/PasswordResetComplete.cs b/API/Controller/Account/PasswordResetComplete.cs index 2c211074..2b38efb7 100644 --- a/API/Controller/Account/PasswordResetComplete.cs +++ b/API/Controller/Account/PasswordResetComplete.cs @@ -30,23 +30,16 @@ public Task PasswordResetComplete([FromRoute] Guid passwordResetI => CompletePasswordReset(passwordResetId, secret, body); /// - /// Complete a password reset process. Deprecated: use POST /password-reset/{id}/{secret}/complete instead. + /// Complete a password reset process. Retired: use POST /1/account/password-reset/{passwordResetId}/{secret}/complete instead. /// /// The id of the password reset /// The secret of the password reset - /// - /// Password successfully changed - /// Password reset process not found - [Obsolete("Use POST /password-reset/{passwordResetId}/{secret}/complete instead.")] + [Obsolete("Retired. Use POST /1/account/password-reset/{passwordResetId}/{secret}/complete instead.")] [HttpPost("recover/{passwordResetId}/{secret}")] - [EnableRateLimiting("auth")] - [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK, MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound + [ApiExplorerSettings(IgnoreApi = true)] [MapToApiVersion("1")] - public Task PasswordResetCompleteLegacy([FromRoute] Guid passwordResetId, - [FromRoute] string secret, [FromBody] PasswordResetProcessData body) - => CompletePasswordReset(passwordResetId, secret, body); + public IActionResult PasswordResetCompleteLegacy([FromRoute] Guid passwordResetId, [FromRoute] string secret) + => Problem(GoneError.EndpointRetired("POST /1/account/password-reset/{passwordResetId}/{secret}/complete")); private async Task CompletePasswordReset(Guid passwordResetId, string secret, PasswordResetProcessData body) { diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 55874247..77e01154 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Models.Requests; using OpenShock.API.Services.Turnstile; +using OpenShock.Common.Errors; using OpenShock.Common.Problems; namespace OpenShock.API.Controller.Account; @@ -24,18 +25,13 @@ public Task PasswordResetInitiateV2([FromBody] PasswordResetReque => PasswordResetInitiate(body, turnstileService, cancellationToken); /// - /// Initiate a password reset. Deprecated: use POST /password-reset instead. + /// Initiate a password reset. Retired: use POST /2/account/password-reset instead. /// - /// Password reset email sent if the email is associated to an registered account - [Obsolete("Use POST /password-reset instead.")] + [Obsolete("Retired. Use POST /2/account/password-reset instead.")] [HttpPost("reset-password")] - [EnableRateLimiting("auth")] - [Consumes(MediaTypeNames.Application.Json)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] + [ApiExplorerSettings(IgnoreApi = true)] [MapToApiVersion("2")] - public Task PasswordResetInitiateV2Legacy([FromBody] PasswordResetRequestV2 body, [FromServices] ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) - => PasswordResetInitiate(body, turnstileService, cancellationToken); + public IActionResult PasswordResetInitiateV2Legacy() => Problem(GoneError.EndpointRetired("POST /2/account/password-reset")); private async Task PasswordResetInitiate(PasswordResetRequestV2 body, ICloudflareTurnstileService turnstileService, CancellationToken cancellationToken) { From 2009b39e24c09abcfb2946042c49e6756a1dea7b Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 24 Jun 2026 23:13:53 +0200 Subject: [PATCH 3/3] Update AccountService.cs --- API/Services/Account/AccountService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 430e14a0..fcb96da6 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -61,7 +61,7 @@ private async Task IsEmailProviderBlacklisted(string email) return await _db.EmailProviderBlacklists.AnyAsync(e => e.Domain == domain); } - private async Task, AccountWithEmailOrUsernameExists>> CreateAccount(string email, string username, string password, bool verifyOnCreation = false) + private async Task, AccountWithEmailOrUsernameExists>> CreateAccount(string email, string username, string password, bool verifyOnCreation) { email = email.ToLowerInvariant(); @@ -96,7 +96,7 @@ await _db.Users /// public async Task, AccountWithEmailOrUsernameExists>> CreateAccountWithActivationFlowAsync(string email, string username, string password) { - var accountCreate = await CreateAccount(email, username, password); + var accountCreate = await CreateAccount(email, username, password, false); if (accountCreate.IsT1) return accountCreate; var user = accountCreate.AsT0.Value; @@ -684,4 +684,4 @@ private async Task CheckPassword(string password, User user) return true; } -} \ No newline at end of file +}