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}`)"