Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions API/Controller/Account/LoginV2.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -45,7 +47,7 @@ public async Task<IActionResult> 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))
{
Expand All @@ -56,9 +58,14 @@ public async Task<IActionResult> 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));
}
}
}
3 changes: 1 addition & 2 deletions API/Controller/Account/PasswordResetInitiateV2.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Net;
using System.Net;
using System.Net.Mime;
using Microsoft.AspNetCore.Mvc;
using Asp.Versioning;
Expand Down
4 changes: 2 additions & 2 deletions API/Controller/Account/SignupV2.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc;
using OpenShock.API.Models.Requests;
using System.Net;
using System.Net.Mime;
Expand Down Expand Up @@ -50,4 +50,4 @@ public async Task<IActionResult> SignUpV2(
_ => Problem(SignupError.UsernameOrEmailExists)
);
}
}
}
6 changes: 6 additions & 0 deletions API/Controller/Tokens/ReportTokens.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,6 +47,10 @@ public async Task<IActionResult> 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;
Expand Down
16 changes: 15 additions & 1 deletion API/Services/Turnstile/CloudflareTurnstileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<CloudflareTurnstileService> _logger;

public CloudflareTurnstileService(HttpClient httpClient, TurnstileOptions options, IHostEnvironment environment, ILogger<CloudflareTurnstileService> logger)
public CloudflareTurnstileService(
HttpClient httpClient,
TurnstileOptions options,
IHostEnvironment environment,
IHttpContextAccessor httpContextAccessor,
ILogger<CloudflareTurnstileService> logger)
{
_httpClient = httpClient;
_options = options;
_environment = environment;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}

Expand Down Expand Up @@ -48,6 +57,11 @@ public async Task<OneOf<Success, Error<CloudflareTurnstileError[]>>> 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")
Expand Down
1 change: 1 addition & 0 deletions Common/Constants/AuthConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions Common/Extensions/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
}

/// <summary>
/// Stores the set of bypass types that the header matched. Called by the bypass middleware.
/// </summary>
public static void SetBypassedTypes(this HttpContext context, BypassTokenType types)
{
ArgumentNullException.ThrowIfNull(context);
context.Items[BypassedTypesItemKey] = types;
}

/// <summary>
/// Returns true if the request presented a bypass token that grants <paramref name="type"/>.
/// </summary>
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",
Expand Down
57 changes: 57 additions & 0 deletions Common/Middleware/BypassTokenMiddleware.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Resolves the <c>X-OpenShock-Bypass-Token</c> header by comparing it to admin-set configuration
/// properties (<c>TURNSTILE_BYPASS_TOKEN</c>, <c>RATE_LIMIT_BYPASS_TOKEN</c>). The matched bypass
/// flags are stored on <see cref="HttpContext.Items"/> so downstream guards (rate limiter selectors,
/// turnstile service) can read them synchronously.
///
/// Runs before <c>UseRateLimiter</c>.
/// </summary>
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<bool> 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));
}
}
9 changes: 9 additions & 0 deletions Common/Models/BypassTokenType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace OpenShock.Common.Models;

[Flags]
public enum BypassTokenType
{
None = 0,
Turnstile = 1 << 0,
RateLimit = 1 << 1
}
2 changes: 1 addition & 1 deletion Common/OpenShockDb/OpenShockContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde
public DbSet<UserNameBlacklist> UserNameBlacklists { get; set; }

public DbSet<EmailProviderBlacklist> EmailProviderBlacklists { get; set; }

public DbSet<DataProtectionKey> DataProtectionKeys { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
Expand Down
5 changes: 5 additions & 0 deletions Common/OpenShockMiddlewareHelper.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -69,6 +70,10 @@ public static async Task<IApplicationBuilder> 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<BypassTokenMiddleware>();

app.UseRateLimiter();

app.UseOpenTelemetryPrometheusScrapingEndpoint(context =>
Expand Down
20 changes: 17 additions & 3 deletions Common/OpenShockServiceHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>? TryBypass(HttpContext ctx)
=> ctx.IsBypassed(Models.BypassTokenType.RateLimit)
? RateLimitPartition.GetNoLimiter("bypass-ratelimit")
: null;

options.OnRejected = async (context, cancellationToken) =>
{
var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>()
Expand Down Expand Up @@ -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<HttpContext, string>(context =>
{
if (TryBypass(context) is { } bypassPartition) return bypassPartition;

var user = context.User;
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading