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..8f8cd793 100644 --- a/API.IntegrationTests/Tests/MailTests.cs +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -86,22 +86,25 @@ 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(); + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email = "whatever@test.org" })); - await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#"); + 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("/1/account/reset", TestHelper.JsonContent(new { email })); - - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var response = await client.PostAsync("/2/account/reset-password", TestHelper.JsonContent(new + { + email = "whatever@test.org", + turnstileResponse = "valid-token" + })); - 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 +143,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 +169,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); } @@ -320,61 +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("/1/account/reset", TestHelper.JsonContent(new { email })); - 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); - 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 response = await client.PostAsync( + $"/1/account/recover/{Guid.CreateVersion7()}/somesecret", + TestHelper.JsonContent(new { password = "LegacyNewPassword456#" })); - var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new - { - email, - password = newPassword - })); - 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("/1/account/reset", TestHelper.JsonContent(new { email })); - 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] @@ -463,10 +432,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 +469,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/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/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..77e01154 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -1,14 +1,11 @@ -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.Errors; using OpenShock.Common.Problems; -using OpenShock.Common.Utils; namespace OpenShock.API.Controller.Account; @@ -28,30 +25,18 @@ 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) { - 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..fcb96da6 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -92,12 +92,6 @@ 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) @@ -690,4 +684,4 @@ private async Task CheckPassword(string password, User user) return true; } -} \ No newline at end of file +} 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."); +}