From b7a36093ac0874ac0e2b58263d5d76c54cf4913d Mon Sep 17 00:00:00 2001 From: Anty Date: Wed, 17 Jun 2026 20:21:11 +0200 Subject: [PATCH] feat: disable signups option --- ...gistrationDisabledWebApplicationFactory.cs | 26 +++++++++ .../Tests/RegistrationDisabledTests.cs | 55 +++++++++++++++++++ API/Controller/Account/Signup.cs | 11 +++- API/Controller/Account/SignupV2.cs | 8 ++- API/Controller/OAuth/SignupFinalize.cs | 6 ++ API/Program.cs | 1 + Common/Errors/SignupError.cs | 5 ++ Common/Extensions/ConfigurationExtensions.cs | 9 +++ Common/Options/AccountOptions.cs | 6 ++ README.md | 31 ++++++----- docker-compose.yml | 1 + 11 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 API.IntegrationTests/RegistrationDisabledWebApplicationFactory.cs create mode 100644 API.IntegrationTests/Tests/RegistrationDisabledTests.cs create mode 100644 Common/Options/AccountOptions.cs diff --git a/API.IntegrationTests/RegistrationDisabledWebApplicationFactory.cs b/API.IntegrationTests/RegistrationDisabledWebApplicationFactory.cs new file mode 100644 index 00000000..f9eec5b8 --- /dev/null +++ b/API.IntegrationTests/RegistrationDisabledWebApplicationFactory.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.Common.Options; + +namespace OpenShock.API.IntegrationTests; + +/// +/// Variant of that runs with user registration disabled. +/// +public sealed class RegistrationDisabledWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + + builder.ConfigureTestServices(services => + { + var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(AccountOptions)); + if (descriptor is not null) + services.Remove(descriptor); + + services.AddSingleton(new AccountOptions { RegistrationEnabled = false }); + }); + } +} diff --git a/API.IntegrationTests/Tests/RegistrationDisabledTests.cs b/API.IntegrationTests/Tests/RegistrationDisabledTests.cs new file mode 100644 index 00000000..70cb777f --- /dev/null +++ b/API.IntegrationTests/Tests/RegistrationDisabledTests.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc; +using OpenShock.API.IntegrationTests.Helpers; + +namespace OpenShock.API.IntegrationTests.Tests; + +/// +/// Verifies that all account-creation endpoints refuse with 403 Signup.RegistrationDisabled +/// when registration is disabled. +/// +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() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = "disabledv2", + password = "SecurePassword123#", + email = "disabledv2@test.org", + turnstileResponse = "invalid-token" + })); + + 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"); + } +} diff --git a/API/Controller/Account/Signup.cs b/API/Controller/Account/Signup.cs index 4aea610d..26be5318 100644 --- a/API/Controller/Account/Signup.cs +++ b/API/Controller/Account/Signup.cs @@ -4,6 +4,7 @@ using Asp.Versioning; using Microsoft.AspNetCore.RateLimiting; using OpenShock.Common.Errors; +using OpenShock.Common.Options; using OpenShock.Common.Problems; using OpenShock.Common.Models; @@ -15,16 +16,24 @@ public sealed partial class AccountController /// Signs up a new user /// /// + /// /// 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 [MapToApiVersion("1")] - public async Task SignUp([FromBody] SignUp body) + 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"), diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 9f707bb0..5bf1a832 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -7,6 +7,7 @@ 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; @@ -19,6 +20,7 @@ public sealed partial class AccountController /// /// /// + /// /// /// User successfully signed up /// Username or email already exists @@ -27,13 +29,17 @@ public sealed partial class AccountController [Consumes(MediaTypeNames.Application.Json)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailOrUsernameAlreadyExists - [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidTurnstileResponse + [ProducesResponseType(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // RegistrationDisabled or InvalidTurnstileResponse [MapToApiVersion("2")] public async Task SignUpV2( [FromBody] SignUpV2 body, [FromServices] ICloudflareTurnstileService turnstileService, + [FromServices] AccountOptions accountOptions, CancellationToken cancellationToken) { + if (!accountOptions.RegistrationEnabled) + return Problem(SignupError.RegistrationDisabled); + var turnStile = await turnstileService.VerifyUserResponseTokenAsync(body.TurnstileResponse, HttpContext.GetRemoteIP(), cancellationToken); if (!turnStile.IsT0) { diff --git a/API/Controller/OAuth/SignupFinalize.cs b/API/Controller/OAuth/SignupFinalize.cs index 4f0d5ef4..9aedbeaa 100644 --- a/API/Controller/OAuth/SignupFinalize.cs +++ b/API/Controller/OAuth/SignupFinalize.cs @@ -7,6 +7,7 @@ using OpenShock.API.Services.OAuthConnection; using OpenShock.Common.Errors; using OpenShock.Common.Extensions; +using OpenShock.Common.Options; using System.Security.Claims; namespace OpenShock.API.Controller.OAuth; @@ -23,6 +24,7 @@ public sealed partial class OAuthController /// Provider key (e.g. discord). /// Request body containing optional Email and Username overrides. /// + /// /// [EnableRateLimiting("auth")] [HttpPost("{provider}/signup-finalize")] @@ -31,8 +33,12 @@ public async Task OAuthSignupFinalize( [FromRoute] string provider, [FromBody] OAuthFinalizeRequest body, [FromServices] IOAuthConnectionService connectionService, + [FromServices] AccountOptions accountOptions, CancellationToken cancellationToken) { + if (!accountOptions.RegistrationEnabled) + return Problem(SignupError.RegistrationDisabled); + // If domain is not supported for cookies, cancel the flow var domain = GetCurrentCookieDomain(); if (string.IsNullOrEmpty(domain)) diff --git a/API/Program.cs b/API/Program.cs index 5dcf07fa..8a92776f 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -26,6 +26,7 @@ var databaseOptions = builder.RegisterDatabaseOptions(); builder.RegisterMetricsOptions(); builder.RegisterFrontendOptions(); +builder.RegisterAccountOptions(); builder.Services .AddOpenShockMemDB(redisOptions) diff --git a/Common/Errors/SignupError.cs b/Common/Errors/SignupError.cs index 66535001..4069048c 100644 --- a/Common/Errors/SignupError.cs +++ b/Common/Errors/SignupError.cs @@ -9,4 +9,9 @@ public static class SignupError "Signup.UsernameOrEmailExists", "The chosen username or email is already in use", HttpStatusCode.Conflict); + + public static OpenShockProblem RegistrationDisabled => new( + "Signup.RegistrationDisabled", + "New account registration is currently disabled", + HttpStatusCode.Forbidden); } \ No newline at end of file diff --git a/Common/Extensions/ConfigurationExtensions.cs b/Common/Extensions/ConfigurationExtensions.cs index cf5b910a..910941e3 100644 --- a/Common/Extensions/ConfigurationExtensions.cs +++ b/Common/Extensions/ConfigurationExtensions.cs @@ -80,6 +80,15 @@ public static MetricsOptions RegisterMetricsOptions(this WebApplicationBuilder b return options; } + public static AccountOptions RegisterAccountOptions(this WebApplicationBuilder builder) + { + var options = builder.Configuration.GetSection("OpenShock:Account").Get() + ?? new AccountOptions(); + + builder.Services.AddSingleton(options); + return options; + } + public static FrontendOptions RegisterFrontendOptions(this WebApplicationBuilder builder) { var section = builder.Configuration.GetRequiredSection("OpenShock:Frontend"); diff --git a/Common/Options/AccountOptions.cs b/Common/Options/AccountOptions.cs new file mode 100644 index 00000000..c8e1ae61 --- /dev/null +++ b/Common/Options/AccountOptions.cs @@ -0,0 +1,6 @@ +namespace OpenShock.Common.Options; + +public sealed class AccountOptions +{ + public bool RegistrationEnabled { get; init; } = true; +} diff --git a/README.md b/README.md index c7d67316..b82b7a48 100644 --- a/README.md +++ b/README.md @@ -14,21 +14,22 @@ https://api.openshock.app/scalar/viewer The API can be configured using the following environment variables: Preferred way is a .env file. -| Variable | Required | Default value | Allowed / Example value | -| ----------------------------------- | -------- | ------------- | -------------------------------------------------------------------------------------------------------- | -| `OPENSHOCK__DB__CONN` | x | | `Host=postgres-server-host;Port=5432;Database=openshock;Username=openshock;Password=superSecurePassword` | -| `OPENSHOCK__DB__SKIPMIGRATION` | | `false` | `true`, `false` | -| `OPENSHOCK__DB__DEBUG` | | `false` | `true`, `false` | -| `OPENSHOCK__FRONTEND__BASEURL` | x | | `https://my-openshock-instance.net` or `https://shocklink.net` | -| `OPENSHOCK__FRONTEND__SHORTURL` | x | | `https://myoi.net` or `https://shockl.ink` | -| `OPENSHOCK__FRONTEND__COOKIEDOMAIN` | x | | `my-openshock-instance.net` | -| `OPENSHOCK__REDIS__CONN` | x | | `redis-server-host:6379` | -| `OPENSHOCK__MAIL__SENDER__EMAIL` | x | | `system@my-openshock-instance.net` | -| `OPENSHOCK__MAIL__SENDER__NAME` | x | | `MyOpenShockInstance System` | -| `OPENSHOCK__MAIL__TYPE` | x | | `MAILJET`, `SMTP` | -| `OPENSHOCK__TURNSTILE__ENABLE` | x | | `true`, `false` | -| `OPENSHOCK__LCG__FQDN` | x | | `de1-gateway.my-openshock-instance.net` `de1-gateway.shocklink.net` | -| `OPENSHOCK__LCG__COUNTRYCODE` | x | | `DE` or `XX` as a placeholder / unknown | +| Variable | Required | Default value | Allowed / Example value | +|-------------------------------------------|----------|---------------|----------------------------------------------------------------------------------------------------------| +| `OPENSHOCK__DB__CONN` | x | | `Host=postgres-server-host;Port=5432;Database=openshock;Username=openshock;Password=superSecurePassword` | +| `OPENSHOCK__DB__SKIPMIGRATION` | | `false` | `true`, `false` | +| `OPENSHOCK__DB__DEBUG` | | `false` | `true`, `false` | +| `OPENSHOCK__ACCOUNT__REGISTRATIONENABLED` | | `true` | `true`, `false` - `false` disables new user sign-ups | +| `OPENSHOCK__FRONTEND__BASEURL` | x | | `https://my-openshock-instance.net` or `https://shocklink.net` | +| `OPENSHOCK__FRONTEND__SHORTURL` | x | | `https://myoi.net` or `https://shockl.ink` | +| `OPENSHOCK__FRONTEND__COOKIEDOMAIN` | x | | `my-openshock-instance.net` | +| `OPENSHOCK__REDIS__CONN` | x | | `redis-server-host:6379` | +| `OPENSHOCK__MAIL__SENDER__EMAIL` | x | | `system@my-openshock-instance.net` | +| `OPENSHOCK__MAIL__SENDER__NAME` | x | | `MyOpenShockInstance System` | +| `OPENSHOCK__MAIL__TYPE` | x | | `MAILJET`, `SMTP` | +| `OPENSHOCK__TURNSTILE__ENABLE` | x | | `true`, `false` | +| `OPENSHOCK__LCG__FQDN` | x | | `de1-gateway.my-openshock-instance.net` `de1-gateway.shocklink.net` | +| `OPENSHOCK__LCG__COUNTRYCODE` | x | | `DE` or `XX` as a placeholder / unknown | Refer to the [Npgsql Connection String](https://www.npgsql.org/doc/connection-string-parameters.html) documentation page for details about `OPENSHOCK__DB_CONN`. Refer to [StackExchange.Redis Configuration](https://stackexchange.github.io/StackExchange.Redis/Configuration.html) documentation page for details about `OPENSHOCK__REDIS__CONN`. diff --git a/docker-compose.yml b/docker-compose.yml index 93d1b101..3c778a52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,7 @@ services: OPENSHOCK__DB__CONN: Host=db;Port=5432;Database=${PG_USER:-openshock};Username=${PG_USER:-openshock};Password=${PG_PASS} OPENSHOCK__REDIS__HOST: redis OPENSHOCK__TURNSTILE__ENABLE: false + OPENSHOCK__ACCOUNT__REGISTRATIONENABLED: true labels: - "traefik.enable=true" - "traefik.http.routers.openshock-api.rule=Host(`${OPENSHOCK_API_SUBDOMAIN:-api}.${OPENSHOCK_DOMAIN:-openshock.local}`)"