diff --git a/API/Controller/Account/LoginV2.cs b/API/Controller/Account/LoginV2.cs index 0fa3e807..445af512 100644 --- a/API/Controller/Account/LoginV2.cs +++ b/API/Controller/Account/LoginV2.cs @@ -1,10 +1,12 @@ -using Microsoft.AspNetCore.Mvc; +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.Extensions; +using OpenShock.Common.Models; using OpenShock.Common.Problems; using OpenShock.Common.Utils; using OpenShock.API.Errors; @@ -45,7 +47,7 @@ public async Task LoginV2( 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)) { @@ -56,9 +58,14 @@ public async Task LoginV2( oauthOnly => Problem(AccountError.AccountOAuthOnly) ); } - + + // Admin accounts must never be authenticated through a bypassed flow — the bypass exists for + // automated tests, not as a credential-less back door to a privileged account. + if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && account.Roles.Contains(RoleType.Admin)) + return Problem(TurnstileError.InvalidTurnstile); + await CreateSession(account.Id, cookieDomain); - + return Ok(LoginV2OkResponse.FromUser(account)); } -} \ No newline at end of file +} diff --git a/API/Controller/Account/PasswordResetInitiateV2.cs b/API/Controller/Account/PasswordResetInitiateV2.cs index 136e0635..0964d2bc 100644 --- a/API/Controller/Account/PasswordResetInitiateV2.cs +++ b/API/Controller/Account/PasswordResetInitiateV2.cs @@ -1,5 +1,4 @@ -using System; -using System.Net; +using System.Net; using System.Net.Mime; using Microsoft.AspNetCore.Mvc; using Asp.Versioning; diff --git a/API/Controller/Account/SignupV2.cs b/API/Controller/Account/SignupV2.cs index 9f707bb0..9feaefc7 100644 --- a/API/Controller/Account/SignupV2.cs +++ b/API/Controller/Account/SignupV2.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using OpenShock.API.Models.Requests; using System.Net; using System.Net.Mime; @@ -50,4 +50,4 @@ public async Task SignUpV2( _ => Problem(SignupError.UsernameOrEmailExists) ); } -} \ No newline at end of file +} diff --git a/API/Controller/Tokens/ReportTokens.cs b/API/Controller/Tokens/ReportTokens.cs index b91c2956..2e79a438 100644 --- a/API/Controller/Tokens/ReportTokens.cs +++ b/API/Controller/Tokens/ReportTokens.cs @@ -9,6 +9,8 @@ using Microsoft.AspNetCore.RateLimiting; using OpenShock.API.Errors; using OpenShock.API.Services.Turnstile; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; using OpenShock.Common.Services.Webhook; namespace OpenShock.API.Controller.Tokens; @@ -45,6 +47,10 @@ public async Task ReportTokens( return Problem(new OpenShockProblem("InternalServerError", "Internal Server Error", HttpStatusCode.InternalServerError)); } + // Admin accounts must never authenticate through a bypassed flow. + if (HttpContext.IsBypassed(BypassTokenType.Turnstile) && CurrentUser.Roles.Contains(RoleType.Admin)) + return Problem(TurnstileError.InvalidTurnstile); + var reportId = Guid.CreateVersion7(); int nAffected = 0; diff --git a/API/Services/Turnstile/CloudflareTurnstileService.cs b/API/Services/Turnstile/CloudflareTurnstileService.cs index 99cac74b..f1f673af 100644 --- a/API/Services/Turnstile/CloudflareTurnstileService.cs +++ b/API/Services/Turnstile/CloudflareTurnstileService.cs @@ -2,6 +2,8 @@ using OneOf; using OneOf.Types; using OpenShock.API.Options; +using OpenShock.Common.Extensions; +using BypassTokenType = OpenShock.Common.Models.BypassTokenType; namespace OpenShock.API.Services.Turnstile; @@ -12,13 +14,20 @@ public sealed class CloudflareTurnstileService : ICloudflareTurnstileService private readonly HttpClient _httpClient; private readonly TurnstileOptions _options; private readonly IHostEnvironment _environment; + private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; - public CloudflareTurnstileService(HttpClient httpClient, TurnstileOptions options, IHostEnvironment environment, ILogger logger) + public CloudflareTurnstileService( + HttpClient httpClient, + TurnstileOptions options, + IHostEnvironment environment, + IHttpContextAccessor httpContextAccessor, + ILogger logger) { _httpClient = httpClient; _options = options; _environment = environment; + _httpContextAccessor = httpContextAccessor; _logger = logger; } @@ -48,6 +57,11 @@ public async Task>> VerifyUserR { if (!_options.Enabled) return new Success(); + // An admin-set bypass secret (matched against the TURNSTILE_BYPASS_TOKEN configuration property) + // counts as a Turnstile pass. The match was resolved upstream by BypassTokenMiddleware. + if (_httpContextAccessor.HttpContext?.IsBypassed(BypassTokenType.Turnstile) == true) + return new Success(); + if (string.IsNullOrEmpty(responseToken)) return CreateError(CloudflareTurnstileError.MissingResponse); if (_environment.IsDevelopment() && responseToken == "dev-bypass") diff --git a/Common/Constants/AuthConstants.cs b/Common/Constants/AuthConstants.cs index 3f1153d5..340e3545 100644 --- a/Common/Constants/AuthConstants.cs +++ b/Common/Constants/AuthConstants.cs @@ -6,6 +6,7 @@ public static class AuthConstants public const string UserSessionHeaderName = "OpenShockSession"; public const string ApiTokenHeaderName = "OpenShockToken"; public const string HubTokenHeaderName = "DeviceToken"; + public const string BypassTokenHeaderName = "X-OpenShock-Bypass-Token"; public const int GeneratedTokenLength = 32; public const int ApiTokenLength = 64; diff --git a/Common/Extensions/HttpContextExtensions.cs b/Common/Extensions/HttpContextExtensions.cs index 49702675..cfe70af0 100644 --- a/Common/Extensions/HttpContextExtensions.cs +++ b/Common/Extensions/HttpContextExtensions.cs @@ -1,10 +1,47 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; using OpenShock.Common.Constants; +using OpenShock.Common.Models; namespace OpenShock.Common.Extensions; public static class HttpContextExtensions { + private static readonly object BypassedTypesItemKey = new(); + + public static bool TryGetBypassTokenFromHeader(this HttpContext context, [NotNullWhen(true)] out string? token) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Request.Headers.TryGetValue(AuthConstants.BypassTokenHeaderName, out var value) && !string.IsNullOrEmpty(value)) + { + token = value!; + return true; + } + + token = null; + return false; + } + + /// + /// Stores the set of bypass types that the header matched. Called by the bypass middleware. + /// + public static void SetBypassedTypes(this HttpContext context, BypassTokenType types) + { + ArgumentNullException.ThrowIfNull(context); + context.Items[BypassedTypesItemKey] = types; + } + + /// + /// Returns true if the request presented a bypass token that grants . + /// + public static bool IsBypassed(this HttpContext context, BypassTokenType type) + { + ArgumentNullException.ThrowIfNull(context); + if (!context.Items.TryGetValue(BypassedTypesItemKey, out var v) || v is not BypassTokenType set) return false; + return (set & type) == type; + } + private static readonly string[] TokenHeaderNames = [ AuthConstants.ApiTokenHeaderName, "Open-Shock-Token", diff --git a/Common/Middleware/BypassTokenMiddleware.cs b/Common/Middleware/BypassTokenMiddleware.cs new file mode 100644 index 00000000..80bc7ee9 --- /dev/null +++ b/Common/Middleware/BypassTokenMiddleware.cs @@ -0,0 +1,57 @@ +using System.Security.Cryptography; +using System.Text; +using OneOf.Types; +using OpenShock.Common.Extensions; +using OpenShock.Common.Models; +using OpenShock.Common.Services.Configuration; + +namespace OpenShock.Common.Middleware; + +/// +/// Resolves the X-OpenShock-Bypass-Token header by comparing it to admin-set configuration +/// properties (TURNSTILE_BYPASS_TOKEN, RATE_LIMIT_BYPASS_TOKEN). The matched bypass +/// flags are stored on so downstream guards (rate limiter selectors, +/// turnstile service) can read them synchronously. +/// +/// Runs before UseRateLimiter. +/// +public sealed class BypassTokenMiddleware +{ + public const string TurnstileConfigKey = "TURNSTILE_BYPASS_TOKEN"; + public const string RateLimitConfigKey = "RATE_LIMIT_BYPASS_TOKEN"; + + private readonly RequestDelegate _next; + + public BypassTokenMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, IConfigurationService config) + { + if (!context.TryGetBypassTokenFromHeader(out var presented)) + { + await _next(context); + return; + } + + var matched = BypassTokenType.None; + + if (await MatchesAsync(config, TurnstileConfigKey, presented)) matched |= BypassTokenType.Turnstile; + if (await MatchesAsync(config, RateLimitConfigKey, presented)) matched |= BypassTokenType.RateLimit; + + if (matched != BypassTokenType.None) context.SetBypassedTypes(matched); + + await _next(context); + } + + private static async Task MatchesAsync(IConfigurationService config, string key, string presented) + { + var result = await config.TryGetStringAsync(key); + return result.TryPickT0(out var configured, out _) + && !string.IsNullOrEmpty(configured) + && CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(configured), + Encoding.UTF8.GetBytes(presented)); + } +} diff --git a/Common/Models/BypassTokenType.cs b/Common/Models/BypassTokenType.cs new file mode 100644 index 00000000..0b2ff4dc --- /dev/null +++ b/Common/Models/BypassTokenType.cs @@ -0,0 +1,9 @@ +namespace OpenShock.Common.Models; + +[Flags] +public enum BypassTokenType +{ + None = 0, + Turnstile = 1 << 0, + RateLimit = 1 << 1 +} diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 2742d711..feddcfc1 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -126,7 +126,7 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet UserNameBlacklists { get; set; } public DbSet EmailProviderBlacklists { get; set; } - + public DbSet DataProtectionKeys { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) diff --git a/Common/OpenShockMiddlewareHelper.cs b/Common/OpenShockMiddlewareHelper.cs index 038304e0..36cee8bc 100644 --- a/Common/OpenShockMiddlewareHelper.cs +++ b/Common/OpenShockMiddlewareHelper.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Middleware; using OpenShock.Common.OpenShockDb; using OpenShock.Common.Options; using OpenShock.Common.Redis; @@ -69,6 +70,10 @@ public static async Task UseCommonOpenShockMiddleware(this await redisConnection.CreateIndexAsync(typeof(DevicePair)); await redisConnection.CreateIndexAsync(typeof(LcgNode)); + // Resolve the X-OpenShock-Bypass-Token header (if present) before rate limiting so the + // rate limiter partition selectors can honor the bypass for this same request. + app.UseMiddleware(); + app.UseRateLimiter(); app.UseOpenTelemetryPrometheusScrapingEndpoint(context => diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 232a2202..1d0fbe96 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -228,6 +228,14 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se return; } + // If the request presented the configured RATE_LIMIT_BYPASS_TOKEN, skip every limiter on it. + // BypassTokenMiddleware (which runs before UseRateLimiter) has already set HttpContext.Items; + // selectors are sync and this is just a dictionary lookup. + static RateLimitPartition? TryBypass(HttpContext ctx) + => ctx.IsBypassed(Models.BypassTokenType.RateLimit) + ? RateLimitPartition.GetNoLimiter("bypass-ratelimit") + : null; + options.OnRejected = async (context, cancellationToken) => { var logger = context.HttpContext.RequestServices.GetRequiredService() @@ -256,6 +264,8 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga // Fixed window at 10k requests allows 20k bursts if burst occurs at window boundary options.GlobalLimiter = PartitionedRateLimiter.Create(context => { + if (TryBypass(context) is { } bypassPartition) return bypassPartition; + var user = context.User; var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) @@ -291,7 +301,9 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga // Authentication endpoints limiter options.AddPolicy("auth", context => { - var ip = context.GetRemoteIP(); + if (TryBypass(context) is { } bypassPartition) return bypassPartition; + + var ip = context.GetRemoteIP().ToString(); return RateLimitPartition.GetFixedWindowLimiter(ip, _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, @@ -300,7 +312,8 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga }); // Token reporting endpoint concurrency limiter - options.AddPolicy("token-reporting", _ => + options.AddPolicy("token-reporting", context => + TryBypass(context) ?? RateLimitPartition.GetConcurrencyLimiter("token-reporting", _ => new ConcurrencyLimiterOptions { PermitLimit = 5, @@ -309,7 +322,8 @@ await context.HttpContext.Response.WriteAsync("Too Many Requests. Please try aga })); // Log fetching endpoint concurrency limiter - options.AddPolicy("shocker-logs", _ => + options.AddPolicy("shocker-logs", context => + TryBypass(context) ?? RateLimitPartition.GetConcurrencyLimiter("shocker-logs", _ => new ConcurrencyLimiterOptions { PermitLimit = 10,